查看原文
其他

云鸽笔记|技术复盘与总结

前端开发 京东科技技术说 2022-03-15

点击「京东数科技术说」可快速关注

「摘要」在日常工作和生活中,写笔记、做文件备份都是非常重要的活动,我们也本着这样的想法,搭建了在公司内部场景下使用的笔记平台——云鸽笔记。如笔记平台的名字一般,希望平台上的笔记能给我们的工作带来更多便捷的信息共享、思想碰撞。

本着信息共享、实现协作的想法,我们从用户使用场景和习惯出发,分别实现了便于工作时间快速操作的PC端、方便非工作时间快速查阅工作信息的M端,以及用来实现多人员协作的云协作。



1云鸽笔记的生态

一、生态

  • 云协作:多人员协作、项目资源共享、信息同步、共享编辑、周报管理等;
  • 云鸽笔记PC端:工作时间、快速操作、随时备忘;
  • 云鸽笔记M端:非工作时间、快速查阅、随时备忘、操作轻量。

二、生态图景

  • 云协作、云鸽笔记PC端
  • 云鸽笔记M端 

三、生态路引

  • 云鸽笔记PC端:https://yunge.jd.com
  • 云鸽笔记M端:进入“京ME”APP,应用下搜索“云鸽笔记”。如无法查到,联系@tongen。
  • 云协作:https://yunge.jd.com/team



2云鸽笔记的项目管理实践


践行敏捷

在整个云鸽笔记的生态中,我们践行着敏捷开发的理念,实现了小版本的快速迭代和版本更新。从大的产品来看,逐步实现云鸽笔记的PC端、M端,继而是云协作。
从小的产品功能来看,我们对产品功能进行了总体的梳理及评级,确定本期要做的功能列表。对本期功能列表进行工时与资源估算,并根据已有人员资源及时间安排,确定迭代版本的每一批功能列表。
在产品实现中,我们团队因没有测试资源及严格的项目管理资源,便就现有资源做了重新分配,并灵活的将测试放在每一个版本的迭代环节里,也充分利用了部门资源,在进行产品MVP验证的时候,同时做了必要的测试与优化提需。
研发人员在每天进行功能开发时,对紧急重要的bugs放在开发计划中,进行必要的代码重构与调整。
以下图示为我们实施中的基本方式。



3云鸽笔记的设计理念


随着移动办公的普及,自带设备移动办公已成为现代办公的重要形式之一。我们本着解决私域用户工作及学习笔记的管理问题,分享和协作等场景进行设计。
从产品的架构来看,我们尽量用简洁的设计语言,功能更加纯粹,减少一些复杂的功能,极力降低用户的使用成本。用高效便捷的操作方式让信息层级更加清晰,增强用户体验。在视觉维度,通过字体模块的大小对比及其色调明度的变化来给用户提供更加舒适的阅读和记录的线上环境。然后通过合理的交互方式减少用户的操作成本和学习成本,缩短用户感知产品的时间,提升产品体验。



4云鸽笔记的实现框架


一、功能模块

在这里,我们对现有功能模块进行了梳理和展示,主要从列表展示、内容展示、用户操作及账号同步、权限设置多方面展开。而这些功能模块的梳理也为我们编写框架提供了依据。

二、框架设计

在框架设计中,结合功能模块的梳理,我们遵循便捷、可复用的原则,采用以下框架完成多端设计与开发。

前端实施中,采用了React+Mobx+Typescript的技术框架,在每一个功能模块中创建其相应的Service,以用来实现应用层API的封装;在Store中实现基础数据的获取或修改,可用于PC与M两个端,而在PC和M两端,分别实现对应其操作行为的数据store。这样我们可以达到通用store在多端的复用,又不会给每个端带来没有负作用的多余代码。
在UI层面,使用Yep-React组件库,搭配构建工具Rocketact/jdwtool,高效完成项目的构建、页面开发及项目的实时预览。

三、图片格式的选择

因为在项目中存在各式各样的图标,它们小而多,并且会随着操作行为发生色值的改变。综合考虑,我们选择使用SVG来控制图标的显示,更多原因如下:
  • 相比传统的图片,尺寸更小,可压缩性更强

  • 可伸缩,更清晰

  • 方便读取和修改

  • 设计软件直接导出



5云鸽笔记的PC端的问题与优化


一、多个svg使用问题

svg的使用方式多种多样,适合自己的才是最好的。下面简单介绍下我们的项目如何在jdwtool脚手架中使用了svg。因为jdwtool基于webpack打包,所以webpack中必不可少需要增加针对svg的配置。代码如下: 

// webpack.config.js
{
test: /\.svg$/,
use: [
{
loader: path.resolve(__dirname, "./fdtsvgloader")
},
"svg-loader"
],
include: [path.resolve(opts.baseDir, "src")],
exclude: [path.resolve(opts.baseDir, "src/svginline")]
}

我们依然使用svg-loader进行svg的处理,但svg-loader返回的是一个包含attributescontent的对象,无法直接使用。处理后的结果如下代码所示:


module.exports = {
attributes: {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 1024 1024'
},
content: '<path d="M441.9 167.3l-19.8-19.8c-4.7-4.7-12.3-4.7-17 0L224 328.2 42.9 147.5c-4.7-4.7-12.3-4.7-17 0L6.1 167.3c-4.7 4.7-4.7 12.3 0 17l209.4 209.4c4.7 4.7 12.3 4.7 17 0l209.4-209.4c4.7-4.7 4.7-12.3 0-17z"/>'
}

因此,在fdt中单独写了一个loader来得到想要的svg格式。代码如下:

// fdtsvgloader.js
module.exports = function(source) {
return `
${source}
var fdtsvg = require('fdt-svg-loader')
module.exports = fdtsvg(module.exports)
`
;
};

// fdt-svg-loader
var React = require("react");
module.exports = function(svg) {
const content = svg.content;
return function(props) {
const newprops = { viewBox: "0 0 1024 1024", height: "20px", fill: "#000" };
newprops.dangerouslySetInnerHTML = { __html: content };
newprops;
return React.createElement("svg", { ...newprops, ...props });
};
};

其中,fdtsvgloader.js中接受的参数即为svg-loader处理后的结果。最后,经过fdt-svg-loader处理后,得到了React创建的svg元素,并包含默认属性viewBox,height以及fill值。因此我们在组件中可以如下方式引用svg:

// demo.tsx
import PptIcon from "@/image/newppt.svg";
export default class Demo extends Component {
render() {
return <PptIcon width="18" height="18" viewBox="0 0 27 34" />;
}
}

demo中传递的属性便可覆盖默认属性,灵活控制svg的大小。至此,我们在项目中愉快的使用svg来控制各式各样图标的显示。

但是突然有一天,在一个慵懒的午后,测试同学突然告诉我,页面中的图标从左边变成了右边的样子。短暂的惊慌之后,我迅速抄起键盘寻找bug的所在之处。
实不相瞒,在做此项目之前,我较少涉猎svg的知识,对于svg并不是很熟悉。因此,寻找bug的过程中遇到了一些困难和挫折。在较短的时间内并没有迅速找到问题所在,首先想到的是重新更换一下图标(设计软件中导出的图标我们做过手动处理)。惊奇的发现,这个方法居然好用,果断完成上线。
事后慢慢琢磨这个事情,一个svg图片并没有受到外部CSS的影响,为什么会突然导致问题呢?为了更快的发现问题,我仔细研究了一下我们的svg图标的结构,学到了一些关于svg的内容:
  • <g>该标签代表组合
  • <defs>定义重用图形
  • <polygon>定义多边形
  • <mask>定义蒙层
  • <use>实现SVG现有图形的重用

既然无法直接找到答案,那只好上排除法来寻找问题所在了。最后发现,问题出现的原因是新引入的图标影响了原有图标。svg互相影响也真的让我非常震惊。

那到底是怎么互相影响的呢?原因就是新的图标中定义了一个mask蒙层,属性id为mask-2。受影响的图标中,path标签的mask属性引用了该mask-2的蒙层,导致新图标的出现影响了部分旧图标。

那么对于直接在html中引入svg,浏览器对于重用图标的寻找机制是怎么样的呢?我们做了如下测试:

<svg width="400" height="300">
<defs>
<linearGradient id='white2black'>
<stop offset="0" stop-color="white"></stop>
<stop offset="100%" stop-color="black"></stop>
</linearGradient>
<mask id="opacity">
<rect x="0" y="0" width="400" height="300" fill="url(#white2black)"></rect>
</mask>
</defs>
<rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect>
<rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect>
</svg>

<svg width="600" height="300">
<defs>
<linearGradient id='white2black'>
<stop offset="0" stop-color="blue"></stop>
<stop offset="50%" stop-color="black"></stop>
<stop offset="100%" stop-color="green"></stop>
</linearGradient>
<mask id="opacity">
<rect x="50" y="0" width="600" height="400" fill="green"></rect>
</mask>
</defs>
<rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect>
<rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect>
</svg>

该代码的展示效果为:

当把第一个svg的mask标签删除之后

<svg width="400" height="300">
<defs>
<linearGradient id='white2black'>
<stop offset="0" stop-color="white"></stop>
<stop offset="100%" stop-color="black"></stop>
</linearGradient>
</defs>
<rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect>
<rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect>
</svg>

<svg width="600" height="300">
<defs>
<linearGradient id='white2black'>
<stop offset="0" stop-color="blue"></stop>
<stop offset="50%" stop-color="black"></stop>
<stop offset="100%" stop-color="green"></stop>
</linearGradient>
<mask id="opacity">
<rect x="50" y="0" width="600" height="400" fill="green"></rect>
</mask>
</defs>
<rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect>
<rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect>
</svg>

效果变为: 

由此可以断定,svg在寻找重用元素时的机制为:在当前HTML环境中寻找第一个匹配的元素。并不是想象中的在svg自己内部寻找或者逐层往外寻找。所以,在html中直接引入svg必然会存在互相影响的问题,也必然会带来一些未知的风险。那么到底有没有较为安全的方案呢?
方法总比困难多,解决方案肯定是存在的。为了防止互相影响,我们使用css中的background-image属性将svg引入,该方案中的svg在寻找重用元素时,仅仅会寻找自身标签内是否存在,而不会向外寻找,因此一定程度上保证了svg图标的安全性。
那以后要一直使用该种方案吗?我觉得还是分场景使用最为合适。例如:一成不变的svg图标可以采用css的方式引入,带有交互行为的图标可以采用html的方式引入,方便修改样式。当然,最重要的就是,对于直接使用的图标,svg内最好干净的仅剩下path标签,这样不会带来任何问题。当然,svg sprites使用use引用时也会存在问题。

二、第三方库引用新姿势

众所周知,React本身推荐两种引用方法,一种是通过HTML的script标签引入React,另一种是使用例如Create-react-app等脚手架启动大型React应用。前者是将React集成到现有项目最简单的方式,而大多数人在使用React框架的时候,往往选择后者,因为后者更方便扩展文件和组件的规模,使用npm的第三方库。
此时,我们需要将React安装在项目的node_modules中,并写入package.json文件的dependence字段,并使用yarn.lock锁住版本。这样,当多人协作完成项目或更换设备初始化代码时都会将所以依赖按照锁定的版本安装,保证项目的正常运行。这是一种很普遍、很常见的用法,具体内容无需赘述。在此,仅介绍一下我们的使用方法。
使用script的方式引入React: 

项目中依然使用: 

同时并不将React安装到项目依赖中。看到这很多人会问,编辑器不会提示找不到react吗?我给出你的答案肯定是不会。
为什么呢?因为脚手架jdwtool做了一步处理。大家都知道,import寻找依赖的机制是从当前项目中的node_module开始,依次往父级查找,最后查找到系统目录,如果在此过程中完成匹配,那查找到此为止。显然,引入的react并不会查找到,因为在系统目录也并未安装,那么到底怎么查找到的呢?这个就需要 tsconfig path 来为我们助力了。
使用tsconfig中的path字段进行路径映射,当项目中无法匹配到依赖时,会按照path中给出的字段进行匹配,直到匹配完成。动态生成的path如下: 

其中,映射的路径为xyz,先从x开始,直到z匹配完成。这里面包含了jdwtool的安装路径,将React安装到了jdwtool的node_modules下。因此,项目中import react时,可从jdwtool的node_modules下查找到react,保证项目的正常运行。
下面,可以看一下项目的dependence字段:

从图中可以看到,项目中并不会安装一些通用的依赖,所有通用的依赖都安装到了jdwtool下,这就是我们整体的实现思路。
这么做到底有什么好处呢?
  • 一次加载,多处使用。加载一次cdn上的react文件,任何请求该文件的项目都可走缓存,而无需请求。

  • 方便升级react版本。大家都知道,react总会给我们带来很多惊喜,尝试新版本总是变的迫不及待。使用这种方法,可以轻松升级react版本,而无需担心重新打包vendor。

  • 脚手架安装依赖,项目初始化更快。很多的依赖都安装在了脚手架中,那么项目中安装的依赖变的非常少,这使得每个人clone下项目后,无需花费过多的时间等待依赖的安装。

  • 脚手架统一升级依赖版本。依赖都安装在脚手架的安装目录下,当需要升级依赖版本时,只需要更新脚手架即可。

看到这也许有人会问,假如某个项目不想使用脚手架的依赖怎么办?这个只需要在项目中安装指定版本的依赖即可。
那么为什么不将antd使用script的方式引入呢?因为antd我们使用了按需加载,而不像react这种全量加载。因此,对于需要全量加载的依赖,可以尝试使用这种方式引入。

三、antd icons 按需加载

前文我们提到过,对于antd这种可以按需加载组件的库,不使用script的方式全量引入。确实,antd支持按需加载组件。实现按需加载组件的方式有两种,一种是单个组件分别引入对应的组件与样式,这种方式代码冗余,因此更多人喜欢第二种使用babel-plugin-import的方式实现按需加载。第二种具体的实现方式网上案例较多,脚手架也加入了对antd按需加载的处理,但这不是本段的主旨,本段的主旨是介绍antd中Icon组件的按需加载。
事情的起因是这样的,我们引用了一个Icon下的一个图标,然后看了下打包后的体积,发现增大了好多。于是查看了打包后的内容发现,antd将所有的Icon的导入了。。。都导入了。。
看一下antd中Icon组件的源码,其中最引入注目的肯定是这段代码:

当调用antd的Icon组件时,会将所有的Icon导入。其实全部导入也没什么,关键是看一下dist的体积,500kb+。使用了一个Icon需要增大500kb的体积,这显然并不合适。因此,我们要寻找一种解决方案。
首先,在工具类目下创建一个文件,名为icons.ts,内容如下:
icons.ts中我们默认导出了两个需要使用的图标,而且项目中业仅仅使用了这两个图标。假如想使用更多的图标,那么也可以在该文件中写入。
其次,webpack设置别名alias。因为脚手架中的webpack配置均从toml文件中读取。所以,需要在toml中配置webpack的alias,代码如下:
第三,在项目中正常使用Icon。
这样一来,当引入Icon时,就把本来要引用的dist指向了自己写的icons.ts。无需全部加载!避免使用一个Icon带来500kb网络开销的负担。


6云鸽笔记的M端的技术难点解析


一、滚动列表的实现

在M端开始进入开发时,PC端已基本完成。同步PC端的代码库,研究列表发现,使用了拥有虚拟滚动条的 InfiniteLoader 的代码库;在M端尝试使用该库,发现生成的页面层级过深,页面较复杂,虽虚拟滚动条看起来会顺畅,我们最终放弃了在M端的应用。
不过我们还是对 InfiniteLoader 做了进一步理解和尝试。比如如何动态设置行高,如何屏蔽在首屏数据未加载的情况下唤起下一次数据请求。
来一波伪代码:
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadmore}
rowCount={count}
threshold={10}
minimumBatchSize={20}
>

{({ onRowsRendered, registerChild }) => (
<AutoSizer>
{({ height, width }) => (
<List
ref={registerChild}
onRowsRendered={onRowsRendered}
deferredMeasurementCache={cache}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
/>

)}
</AutoSizer>
)}
</InfiniteLoader>
const rowRenderer = ({ key, index, style, parent }) => {
return (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
<div
className="infinitelist-item"
>
</div>
</CellMeasurer>

)
};
其中 CellMeasurer 组件可以实现动态设置元素行高;而threshold 、minimumBatchSize 和 defaultHeight等混合应用才能解决不要在首屏首次列表数据未加载完毕时发起再次请求的问题。

二、列表页面切换保持切换前滚动高度

页面在切换时,能保持原页面的滚动高度,可以大大提升用户体验,不会让用户因在切换之间页面跳来跳去,有失控的感觉。还是回到刚才的 InfiniteLoader 组件,很强大,提供了上一个页面离开时的滚动高度。不过因为已废弃掉该组件在项目中的应用,需要自己来实现。不如把页面滚动和页面跳转间的关系画下来,按图索骥,便会一目了然。
上图已比较清晰的展示了页面滚动及页面跳转,只需找个地方把跳转前页面的scrolltop值存储下来,等再次回到该页面是,再取来去做跳转就好了。以下是伪代码展示:
// 写
setScrollTopByPager(page: string, top: number) {
this.topHash[page] = top;
};

// 读
getScrollTopByPage(page: string) {
return this.topHash[page];
};

// 侦听滚动
scrollNode.addEventListener('scroll', () => {
this.setScrollTopForRoutor(
this.pageId,
scrollNode.scrollTop
);
})

// 再次进入页面时读取并设置
let top = this.getScrollTopByPage(pageId);
scrollNode.scrollTo(0, top);

三、笔记的编辑和预览

作为云鸽笔记的核心功能,我们的定位是支持尽可能丰富的笔记格式和文档格式。对于笔记格式,支持常用的富文本及markdown格式,这两种格式提供编辑及预览;对于文档格式,关注下载及预览。
在编辑器方面,本期优先选择了第三方的编辑器类库,以快速实现MVP的验证。其中富文本编辑器选择了“braft-editor”;MarkDown选择使用“tui-editor”。因为目前两者都比较成熟,使用较为简单,可更多关注内容的实现。
在文件预览方面,我们提供较为丰富的文件格式的在线预览,如大家常用的Office系列文档格式、PDF格式,以及图片,还有作为程序员常用的代码类文件格式。
在设定目标及初步解决方案后,我们在实施过程中也遇到了一些问题,有些也是兼容性问题,以下一一展开。
四、MD 和富文本编辑
问题一:markdown和富文本编辑器在编辑状态下,无法滚动到底部,有一部分被手机键盘遮挡。
原因在于 android 和 iOS 在键盘弹起和收起时页面的行为是完全不一样的——
  • iOS:iOS的键盘在窗口的最上层,当键盘弹起时,webview的高度并不会发生变化,但是scrollTop会发生变化,页面发生滚动,而页面可滚动的最大高度是弹出的键盘的高度;且只有当键盘弹起时页面滚动到底部时, scrollTop 的变化值才为键盘高度。

  • Android:webview留出键盘空间,键盘弹起时页面高度会改变,内容区域会减少,页面不会发生滚动,flex布局下会压缩页面。

针对iOS下这种情况,各种社区也提供了很多的解决方案,基本都是关于input标签fixed布局失效的案例,但是我们的场景比较特殊,笔记编辑的时候整个内容区域都是可编辑区,这就导致总有一部分内容会被键盘挡住无法被看见和选中编辑。
也尝试过使用以下方式
input.scrollIntoView(true);
input.scrollIntoViewIfNeeded();

另外我们希望笔记标题在顶部固定,内容区域和可编辑区域可滚动,因此采用了flex布局。但场景中可编辑区已经处于可视区域,所以并没能解决问题,还有重要的一点是键盘弹起之后编辑区域也是可滚动的,依然会有无法滚动到底部的问题,键盘还是会遮挡一部分内容。

这种场景并不一样,经过几番尝试均未达到想要的效果,于是尝试改变布局方案,不使用flex布局,使用最简单的流体布局,监听页面滚动,动态固定笔记标题。
let noteHeader = document.querySelector('.note-header');
window.addEventListener('scroll', this.scrollFn, false);
this.scrollFn = () => {
let positionTop = document.body.scrollTop;
noteHeader.style.top = positionTop + 'px';
}

这样能让标题固定在顶部,由js动态控制,体验上差一些。键盘遮挡内容的问题还是没有解决,经过反复的实验,发现其实整个内容区域并没有滚动上去,键盘下面的位置还有一部分内容,因此需要在键盘弹起的时候将内容向上顶起,使这部分内容处于键盘上方,也就是在键盘弹起后给内容区域加 paddingBottom 属性,键盘下面区域用空白填充,内容即可滚动到键盘上方,先来实验一下。

let focusinFn = () => {
let content = document.querySelector('.content');
content.style.paddingBottom = '7.5rem';
}

window.addEventListener('focusin', focusinFn, false);
实验成功,可以按照这个思路继续优化了,这期是按照常规的最高键盘高度来设置的paddingBottom值来实现这个效果,键盘收起的时候重置成初始的状态,后续优化这快高度的设置。
这块的实现比较粗糙,还有很大的优化空间,或许还有更好的解决方案。以此抛砖引玉,希望看到大家的想法。
五、PDF 和 Office 文件预览
问题二:office文件预览,无法放大缩小,上层样式的影响无法滚动;
问题三:pdf存在无法滚动和缩放的问题,体验很差。
原因主要是使用html标签在页面中渲染无法缩放和滚动。在预览PDF中,使用了 embed 标签加载;而Office文件使用了 iframe 加载。但无法滚动和缩放,使阅读体验大打折扣。后来我们改进了此方法,先下载文件,再使用手机的文件预览功能打开文档进行预览,实验成功。而收获的意外体验是,这时可以友好的提示用户是否要下载当前文档,给了用户更多的控制权。
axios({
method: "get",
url: `downloadurl`,
responseType: "blob",
onDownloadProgress: (progressEvent) => {
onDownloadProgress && onDownloadProgress(progressEvent);
}
}).then(function(response) {
var a = document.createElement("a");
var url = window.URL.createObjectURL(response.data);
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
});

六、与京ME应用的打通

我们的产品定位是基于内部私域的实现,所以在账号体系的打通上我们使用了公司的ERP账号体系。而京ME是基于全员的必备APP,也是部署产品的重要入口。
与此同时,京ME也为我们提供了一些必要的提高产品体验的API,比如唤起手机拍照或相册、是否是wifi环境、分享功能;京ME还提供自定义导航栏;对于产品验证与发布,京ME还提供白名单测试及灰度发布。
不过在接入京ME中也遇到了一些问题。比如分享功能,可以分享给私域之外的地方,如微信微博朋友圈,而这些是我们不需要的,关于定制化的分享功能,则需要像京ME提需;同样还是分享功能,云鸽分享链接,因为咚咚在处理链接时出现了正则匹配问题,出现了跨平台间不同的展示结果,比如同一个链接在安卓下打开失败,iOS下可用,而分享卡片的协议跳转,有时在iOS下不可用,而安卓OK。
在开发过程中,由于京Me接入只支持java服务端,暂时不支持nodejs服务端,再了解京Me服务端认证算法后,我们同步在nodejs中做了实现,同时将相关功能封装为koa中间件 @jd/koa-jdme-passport ,具体使用可参考该中间件使用文档。
其间,京ME产品和研发小伙伴都鼎立协助我们解决问题,只是最终需要京ME发版,我们需要过一段时间再发布分享功能。



7云鸽笔记的复盘总结


经过这一段时间的紧凑型项目开发及产品体验的提升,我们在项目中充分利用部门内部的工具资源,包括设计协作效率工具REALY平台(视觉稿一键标注),前端移动端UI组件库Yep-react,内部构建工具等;对我们现有工具的实施与完善也做了进一步的验证和优化建议。
最重要的是,我们提出的产品方案,填补了内部笔记管理及团队间协作的空白,给笔记需求的伙伴一个很好的平台,也给需要文档协作的团队一个很好的选择。
最后,欢迎您随时使用云鸽笔记,有任何优化建议都可以提给我们,一起打磨更优质、有价值的笔记产品。



京东数科技术说&技术课堂

   ▼▼▼     

由京东数科-数字技术中心策划组织

倡导“原创·实用·技术·专业”

致力于分享技术领域实战经验与技术干货

线上订阅“京东数科技术说”,线下聆听“技术课堂”

为加强技术分享、总结沉淀,提升数科技术影响力而搭建的

线上线下融合交流平台

不只一技之长 · 我有N技在手

 咨询、建议、合作请联系:

刘嘉璐(liujialu)/张明瑛(zhangmingying3)

长按识别二维码关注我们

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存